最近都在整理過去的文章,好久沒有發文,趁鐵人賽開賽前最後一發。
之前的文章有提到 Cookie-Based
和 Token-Based
兩種授權驗證方式,另一篇 實作了 Cookie-Based,而今天要介紹的就是第二種 Token-Based 授權驗證。
使用 Token 有那些好處呢?
什麼是 JWT (Json Web Token):
JWT 是網路上常見的 Token 類型,詳細規範可參考 RFC7519,
包含三個部分 header
、payload
、signature
,並使用 .
串聯起來,結構如下。[2]
header.payload.signature
Header
主要包含兩個資訊,加密演算法
和 Token 的類型
,並使用 base64 編碼。
{
"alg": "HS256",
"typ": "JWT"
}
Payload
用來存放使用者的基本資料和相關的驗證資訊,並使用 base64 編碼。
{
"UserId": "A01",
"UserName": "王小明",
"exp": "100000000" //過期時間
}
Signature
確保資料完整性的雜湊簽章,由 Header 和 Payload 經過 base64 編碼後用 . 串接,再使用 HS256 加密後得到。
HMACSHA256(
base64UrlEncode(Header) + "." +
base64UrlEncode(Payload),
secret
);
如何使用
使用時放在 Header 內的 Authorization
標頭,並在 Token 前方加上 Bearer
關鍵字。
Authorization: Bearer header.payload.signature
以上為 JWT 簡介,不過可以發現 Token 主體只經過 base64 編碼,並沒有經過加密,因此內含的資訊等於是明碼,並沒有受到保護。
雖然 Token 內不會存放敏感性的資料,但還是希望可以經過加密,隱藏資料結構,因此稍微做了修改。
修改後的 Payload
使用 base64 編碼後,再使用 AES 加密,AES 需要 KEY 和 IV,其中 IV 建議不要每次都相同,詳細原理沒有深入研究,可以參考 這篇。
AES(
base64UrlEncode(Payload),
secret, //密鑰
iv //IV
);
修改後的 Header
Header 目前用不到就拿來放 IV 吧,哈哈哈。
iv
修改後的 Signature
HMACSHA256(
iv +
base64UrlEncode(Payload),
secret //密鑰
);
Token 結構
Authorization: iv.payload.signature
接著要討論 Token 的換發流程,JWT 有個缺點,因為所有資訊都寫在 Token 內,雖然驗證時不必經過資料庫,但我們也不能透過資料庫銷毀 Token,只能靠設定過期時間讓它自己過期。
考慮到 Token 通常是給 APP 使用,如果像網頁一樣失效後,需重新輸入帳號密碼登入,一定會被嫌使用者體驗不好,但又不能將過期時間設太長,因為如果 Token 不小心被竊取,該帳號將會長時間處於不安全狀態,竊取者可以用此 Token 做任何事,我們無法讓其失效。
因此有人提出了 Refresh Token
的概念,當 Token 過期後可用 Refresh Token 換取新的,其存活時間可以很長。這樣我們就能將 Token 過期時間縮短,過期後再用 Refresh Token 換取新的就好,同時兼顧安全性和使用者體驗。[3]
到這裡一定有人會問 Refresh Token 如果被竊取呢,因為 Refresh Token 會儲存在資料庫內,所以可以透過刪除的方式銷毀 Refresh Token。那既然都能竊取,換一次偷一次呢,ㄜ...這就不在這篇的討論範圍內了,要記得網路上沒有絕對的安全,只能盡量優化我們的安全機制。
Token 和 Refresh Token 的換發方式:
{
"access_token":"l0XG52TQx", //Token
"refresh_token":"KWI3JOkFA", //Refresh Token
"expires_in":3600 //幾秒過期
}
Token-Based 登入流程:
登入流程 Token-Based
和 Cookie-Based
差不多,可以參考 另一篇 文章的 登入流程圖
,兩者差在 Cookie-Based 將驗證資訊藉由 Cookie 送到後端,而 Token-Based 則是將 Token 放在 Header 的 Authorization
標頭送到後端。
新增 Token
類別定義回傳的 Token 結構。
public class Token
{
//Token
public string access_token { get; set; }
//Refresh Token
public string refresh_token { get; set; }
//幾秒過期
public int expires_in { get; set; }
}
新增 Payload
類別,這裡稍微修改了 Payload 結構,將使用者資訊和過期時間分開。
public class Payload
{
//使用者資訊
public User info { get; set; }
//過期時間
public int exp { get; set; }
}
資料會像。
{
"info": {
"UserId": "A01",
"UserName": "王小明"
},
"exp": "100000000"
}
新增 TokenCrypto
處理 AES 加解密
和 產生 HMACSHA256 簽章
。
public static class TokenCrypto
{
//產生 HMACSHA256 雜湊
public static string ComputeHMACSHA256(string data, string key)
{
var keyBytes = Encoding.UTF8.GetBytes(key);
using (var hmacSHA = new HMACSHA256(keyBytes))
{
var dataBytes = Encoding.UTF8.GetBytes(data);
var hash = hmacSHA.ComputeHash(dataBytes, 0, dataBytes.Length);
return BitConverter.ToString(hash).Replace("-", "").ToUpper();
}
}
//AES 加密
public static string AESEncrypt(string data, string key, string iv)
{
var keyBytes = Encoding.UTF8.GetBytes(key);
var ivBytes = Encoding.UTF8.GetBytes(iv);
var dataBytes = Encoding.UTF8.GetBytes(data);
using (var aes = Aes.Create())
{
aes.Key = keyBytes;
aes.IV = ivBytes;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
var encryptor = aes.CreateEncryptor();
var encrypt = encryptor
.TransformFinalBlock(dataBytes, 0, dataBytes.Length);
return Convert.ToBase64String(encrypt);
}
}
//AES 解密
public static string AESDecrypt(string data, string key, string iv)
{
var keyBytes = Encoding.UTF8.GetBytes(key);
var ivBytes = Encoding.UTF8.GetBytes(iv);
var dataBytes = Convert.FromBase64String(data);
using (var aes = Aes.Create())
{
aes.Key = keyBytes;
aes.IV = ivBytes;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
var decryptor = aes.CreateDecryptor();
var decrypt = decryptor
.TransformFinalBlock(dataBytes, 0, dataBytes.Length);
return Encoding.UTF8.GetString(decrypt);
}
}
}
新增 TokenManager
類別管理產生 Token 和取出使用者資訊的操作。
public class TokenManager
{
//金鑰,從設定檔或資料庫取得
public string key = "AAAAAAAAAA-BBBBBBBBBB-CCCCCCCCCC-DDDDDDDDDD-
EEEEEEEEEE-FFFFFFFFFF-GGGGGGGGGG";
//產生 Token
public Token Create(User user)
{
var exp = 3600; //過期時間(秒)
//稍微修改 Payload 將使用者資訊和過期時間分開
var payload = new Payload
{
info = user,
//Unix 時間戳
exp = Convert.ToInt32(
(DateTime.Now.AddSeconds(exp) -
new DateTime(1970, 1, 1)).TotalSeconds)
};
var json = JsonConvert.SerializeObject(payload);
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
var iv = Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
//使用 AES 加密 Payload
var encrypt = TokenCrypto
.AESEncrypt(base64, key.Substring(0, 16), iv);
//取得簽章
var signature = TokenCrypto
.ComputeHMACSHA256(iv + "." + encrypt, key.Substring(0, 64));
return new Token
{
//Token 為 iv + encrypt + signature,並用 . 串聯
access_token = iv + "." + encrypt + "." + signature,
//Refresh Token 使用 Guid 產生
refresh_token = Guid.NewGuid().ToString().Replace("-", ""),
expires_in = exp,
};
}
//取得使用者資訊
public User GetUser()
{
var token = HttpContext.Current.Request.Headers["Authorization"];
var split = token.Split('.');
var iv = split[0];
var encrypt = split[1];
var signature = split[2];
//檢查簽章是否正確
if (signature != TokenCrypto
.ComputeHMACSHA256(iv + "." + encrypt, key.Substring(0, 64)))
{
return null;
}
//使用 AES 解密 Payload
var base64 = TokenCrypto
.AESDecrypt(encrypt, key.Substring(0, 16), iv);
var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64));
var payload = JsonConvert.DeserializeObject<Payload>(json);
//檢查是否過期
if (payload.exp < Convert.ToInt32(
(DateTime.Now - new DateTime(1970, 1, 1)).TotalSeconds))
{
return null;
}
return payload.info;
}
}
新增 TokenController
測試功能。
[RoutePrefix("api/token")]
public class TokenController : ApiController
{
private TokenManager _tokenManager;
public TokenController()
{
_tokenManager = new TokenManager();
}
//紀錄 Refresh Token,需紀錄在資料庫
private static Dictionary<string, User> refreshTokens =
new Dictionary<string, User>();
//登入
[HttpPost]
[Route("signIn")]
public Token SignIn(SignInViewModel model)
{
//模擬從資料庫取得資料
if (!(model.UserId == "abc" && model.Password == "123"))
{
throw new Exception("登入失敗,帳號或密碼錯誤");
}
var user = new User
{
Id = 1,
UserId = "abc",
UserName = "小明",
Identity = Identity.User
};
//產生 Token
var token = _tokenManager.Create(user);
//需存入資料庫
refreshTokens.Add(token.refresh_token, user);
return token;
}
//換取新 Token
[HttpPost]
[Route("refresh")]
public Token Refresh([FromBody]string refreshToken)
{
//檢查 Refresh Token 是否正確
if (!refreshTokens.ContainsKey(refreshToken))
{
throw new Exception("查無此 Refresh Token");
}
//需查詢資料庫
var user = refreshTokens[refreshToken];
//產生一組新的 Token 和 Refresh Token
var token = _tokenManager.Create(user);
//刪除舊的
refreshTokens.Remove(refreshToken);
//存入新的
refreshTokens.Add(token.refresh_token, user);
return token;
}
//測試是否通過驗證
[HttpPost]
[Route("isAuthenticated")]
public bool IsAuthenticated()
{
var user = _tokenManager.GetUser();
if (user == null)
{
return false;
}
return true;
}
}
使用 Postman。
1.登入
api/token/signIn
成功回傳 Token。
2.是否通過驗證
api/token/isAuthenticated
回傳 true 表示通過驗證。
2.使用 Refresh Token 換取新 Token
api/token/refresh
成功換取新的 Token。
這裡採到一個坑,一開始用 key-value
的方式傳值,但 Web Api 怎麼都收不到,去看了官方 文件 才發現雖然可以用 [FromBody]
綁定參數到 簡單型別 (string、int)
上,
public Token Refresh([FromBody]string refreshToken)
但前端傳到後端的值不能有 key,例如:
=value
接著我就將 key 刪除再送出,結果還是一樣出錯,點右上角的 Code 看看。
畫面參數正確,但 Web Api 還是收不到,想說用 Fiddler 欄看看好了,最後發現被 Postman 騙了...參數沒有送出阿,畫面上是假的!!!
後來選 raw
才可以。
這篇簡單實作了 Token 驗證和換發的流程,程式的部分如果是正式使用,錯誤訊息還需要再詳細一些,例如驗證簽章、檢查是否過期、等等。
在 Refresh Token 設計上還有一些要注意的地方,例如一位使用者可以同時擁有幾個 Refresh Token 呢,如果只能有一個,那麼一位使用者就只能登入一台裝置,因為另一台裝置持有的 Refresh Token 會在登入時會被覆蓋掉。而如果不限制,隨著登入次數增加 Refresh Token 有可能會無限成長,好像也不是很恰當。
參考了 Google 的做法,Google 會限制單個應用程式授權最多擁有 50 個 Refresh Token,超過則會從舊的開始失效,像上面兩者的折衷辦法。[9]
這篇介紹的 Token 機制,可以用在單一網站或自家的 APP 串接,但如果要像 Google、FB 提供給第三方應用串接,則需實作完整的 OAuth 機制,這篇介紹的還不是完整的 OAuth,算是簡化的版本,完整的下次有機會再分享。
這篇就到這裡摟,感謝大家觀看。
[1] 講真,別再使用JWT了|洞見
[2] [ASP.NET WebApi]使用JWT進行web api驗證
[3] OAuth 2.0 筆記 (5) 核發與換發 Access Token
[4] 浅谈使用Json Web Token和Cookie的利弊
[5] 使用Asp.Net MVC打造Web Api (14) - 確保傳輸資料的安全
[6] C# DateTime与时间戳转换
[7] .NET Core - AES 加解密
[8] C# base64加密與解密
[9] Using OAuth 2.0 to Access Google APIs
請問要怎麼在API裡 取到playload 的userID
在登入的情況下,可以使用 GetUser
取得使用者資訊。
(登入情況是指 Authorization
header 帶有 token)
var user = _tokenManager.GetUser();